feat(ir)!: add Deprecation.RemovalDate; promote x-extensible-enum - #439
Conversation
5ec90fd to
e8d1d1f
Compare
151cb37 to
82990ae
Compare
82990ae to
4b14fe3
Compare
Wahbeh-Mohammad
left a comment
There was a problem hiding this comment.
Two things before this goes in — one behavioural, one on the title.
The PR title is over the squash cap
feat(ir)!: give Deprecation a RemovalDate; open Enum from x-extensible-enum is 75 characters on its own and 82 once GitHub appends (#439):
$ printf '%s (#439)' 'feat(ir)!: give Deprecation a RemovalDate; open Enum from x-extensible-enum' | wc -c
82
The cap is 72 including the number, so a three-digit PR leaves 65. Both commit subjects on the branch are comfortably inside it (67 and 70), which is the usual shape of this: the overrun arrives only through the squash title, and the squash title is what lands on main.
Two replacements that fit, same form, still breaking:
$ printf '%s (#439)' 'feat(ir)!: add Deprecation.RemovalDate; promote x-extensible-enum' | wc -c
72
$ printf '%s (#439)' 'feat(ir)!: add Deprecation.RemovalDate; promote enum openness' | wc -c
68
The first names both concrete facts and lands exactly on the cap; the second trades the key for the fact it states and keeps four characters of room. I'd take the first.
4b14fe3 to
bcb4046
Compare
|
Retitled to the first of your two: |
bcb4046 to
fffd1f6
Compare
fffd1f6 to
eb5d83e
Compare
x-sunset echoes RFC 8594's Sunset header, which is a date by definition, but the default promotion mapping read it into Deprecation.RemovalVersion — a field whose name, doc comment and sibling all say version. A consumer deciding whether removing a deprecated operation is breaking compares a sunset against a release date, and could not tell which spelling it had been handed without re-parsing the string. Take issue #417's option 1: a distinct RemovalDate beside RemovalVersion, with x-sunset promoting to the date. A version and a date are two facts, not two spellings of one — a document may state both ("gone in 3.0.0", "gone on 2026-08-01"), and neither is derivable from the other without a release calendar the IR does not have. A single field carrying which spelling it holds (option 2) would have to drop whichever fact it read second, so it costs losslessness to buy nothing a second field does not already give: the field a value arrives in is what says which fact it is. Deliberately out of scope, and stated in ir-design.md and at the reading site: RemovalDate is the source's own text, neither parsed nor normalized. No source format defines the field, so none defines its format; and the key→field mapping is caller policy, so a key pointed at the date field is the caller's statement that it holds a date. Morphic records which fact was stated and leaves the calendar to the consumer. BREAKING CHANGE: Deprecation gains removalDate, and x-sunset now fills it instead of removalVersion. A consumer reading removalVersion for a sunset reads an empty field until it moves. No default key names RemovalVersion any more — a document stating a removal version names its own key, per promotion rule 1 — so the corpus stops witnessing that field and it joins unwitnessed.golden.txt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
ir.Enum has carried a Closed bool since the IR was written, and the OpenAPI compiler set it true at both construction sites unconditionally. So the one key the format has for saying an enum is open, x-extensible-enum, survived only as a generic vendor_extension entry, and every consumer reading typed fields saw a closed enum whatever the document said. Open versus closed decides whether a generator emits a fallback member and whether a differ calls an added value breaking, so this was a wiring gap, not a modelling one. Add TargetEnumOpen to the promotion vocabulary, map x-extensible-enum onto it by default, and apply it in attachDeclaredAnnotations beside the deprecation promotion — the point at which a declaration's extensions have reached the node's map, which is what makes "the extension survives its own promotion" structural here as it is there. Every promotion property holds unchanged: the entry stays put with its vendor_extension reason, the node records extension-promotion in Provenance.Inferred, and a disabled policy writes nothing. The target names the fact rather than the field, which the rest of the vocabulary does not. Openness is the only half of that bool a document ever declares — a schema's `enum` is closed by definition — so a target named for Closed could only ever be written false and would read as its own opposite at every mapping naming it. For the same reason the key's presence is the statement rather than its value: the established spelling writes the member list as the value, and a list of members says nothing about openness the key naming it has not already said. A boolean is the one shape that does state it alone, so an explicit `false` is read as written rather than inverted. Deliberately out of scope, and stated in ir-design.md and at the reading site: a document writing x-extensible-enum *instead* of `enum`, with the members in the extension, lowers to no ir.Enum at all and there is no node to open. Minting one would be reading a member list out of a vendor key rather than promoting a field; the entry survives verbatim for a consumer that wants to. The corpus can now witness the matrix's open-enums row, so its matrixRowsUncovered reason is deleted rather than left to go stale, and extension-promotion.yaml gains the three enums that pin the three answers the reading has: the convention opens one, an explicit false declines to, and an enum naming no such key is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
JSON null decodes into a bool without error and leaves it false, so a bare `x-extensible-enum:` — the presence-only spelling the reading exists for — read as the explicit false that is the one way a document declines, with no diagnostic. Decode into *bool so absence of a value is told apart from false, and pin the null shape beside the list, the true and the prose the test already covers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011T5no6iADeMGgYjsYcV5in
eb5d83e to
aa4a2fb
Compare
Closes #417 and #427. Breaking — one IR field addition.
Deprecation.RemovalDate(#417)x-sunsetwas promoted intoRemovalVersion, a field documented as "the version in which the entity is scheduled for removal". Butx-sunsetcarries an ISO date by convention — it echoes RFC 8594'sSunsetheader, which is a date by definition.Option 1 from the issue: a distinct field. A version and a date are two facts, not two spellings of one. A document may state both ("gone in 3.0.0", "gone on 2026-08-01") and neither is derivable from the other without a release calendar the IR does not have — so a single field carrying which spelling it holds would have to drop whichever it read second. A consumer tells them apart by which key the value arrived under, with no re-parsing.
RemovalDateis the source's own text, neither parsed nor normalized: no source format defines the field, so none defines its format.Behaviour worth flagging: no default key names
RemovalVersionany more, so the corpus stops witnessing it and it gains a line inunwitnessed.golden.txt. Inventing anx-*spelling for a removal version would be a convention morphic made up.x-extensible-enum→Enum.Closed(#427)The IR already had exactly the field this extension is the OpenAPI spelling of, and the compiler hardcoded
Closed: true, so the extension had no effect and survived only as a generic vendor extension. Correctly not marked breaking: the field's meaning is unchanged, only the compiler's computed value.BREAKING CHANGE:
DeprecationgainsremovalDateandx-sunsetroutes there. A consumer reading a removal date offremovalVersionnow finds it empty.Stack 5 of 8. Base
stack/4-errorcase— review and merge bottom-up. Every commit here passedmake gatewhen it landed, and the full gate was re-run on the top of the stack. Run it asGOTOOLCHAIN=go1.26.3 make gate; this machine's Go 1.27 fails it for reasons unrelated to any change (#431).🤖 Generated with Claude Code
https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1